//基于WebSocket和WebRTC的多用户聊天示例与双向视频
//打电话，包括在适当或必要时轮流使用。
//
//这个文件包含实现客户端的JavaScript代码
//用于连接和管理聊天和视频通话的功能。
//
//阅读这个示例的工作原理:http://bit.ly/webrtc-from-chat
//
//任何版权都属于公共领域。
// http://creativecommons.org/publicdomain/zero/1.0/

"use strict";

//获取我们的主机名

var myHostname = window.location.hostname;
if (!myHostname) {
  myHostname = "localhost";
}
log("主机名: " + myHostname);

// WebSocket聊天/信令通道变量。

var connection = null;
var clientID = 0;

//媒体约束对象描述了我们想要什么样的流
//从本地的A/V硬件(通常是一个网络摄像头和
//麦克风)。这里，我们只指定同时需要audio和
//视频;然而，你可以更具体。这是可以陈述的
//你更喜欢(或要求)特定分辨率的视频，
//是选择面向用户的摄像头，还是选择后置摄像头(如果有的话)，
//等等。
//
//参见:
// https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamConstraints
// https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia
//

var mediaConstraints = {
  audio: true,            //我们想要一个音轨
  video: {
    aspectRatio: {
      // ideal: 1.333333     // 4:3比例优先
      ideal: 1.777777     // 16:9比例优先
    }
  }
};

var myUsername = null;
var targetUsername = null;      //存储其他同行的用户名
var myPeerConnection = null;    // RTCPeerConnection
var transceiver = null;         // RTCRtpTransceiver
var webcamStream = null;        // 来自网络摄像头的 MediaStream 流

//将日志信息输出到控制台。

function log(text) {
  var time = new Date();

  console.log("[" + time.toLocaleTimeString() + "] " + text);
}

//向控制台输出一条错误消息。

function log_error(text) {
  var time = new Date();

  console.trace("[" + time.toLocaleTimeString() + "] " + text);
}

//将JavaScript对象转换为JSON并发送
//作为WebSocket连接上的消息。

function sendToServer(msg) {
  var msgJSON = JSON.stringify(msg);

  log("发送 '" + msg.type + "' message: " + msgJSON);
  connection.send(msgJSON);
}

//当收到“id”消息时调用;此消息由
//服务器指定此登录会话的唯一ID号;作为回应,
//这个函数发送一个“username”消息来设置我们的用户名
//会话。
function setUsername() {
  myUsername = document.getElementById("name").value;

  sendToServer({
    name: myUsername,
    date: Date.now(),
    id: clientID,
    type: "username"
  });
}

//打开并配置到WebSocket服务器的连接。

function connect() {
  var serverUrl;
  var scheme = "ws";

  //如果这是一个HTTPS连接，我们必须使用一个安全的WebSocket
  //连接也是，所以在方案中添加另一个“s”。

  if (document.location.protocol === "https:") {
    scheme += "s";
  }
  serverUrl = scheme + "://" + myHostname + ":6503";

  log(`连接到服务器:: ${serverUrl}`);
  connection = new WebSocket(serverUrl, "json");

  connection.onopen = function(evt) {
    document.getElementById("text").disabled = false;
    document.getElementById("send").disabled = false;
  };

  connection.onerror = function(evt) {
    console.dir(evt);
  }

  connection.onmessage = function(evt) {
    var chatBox = document.querySelector(".chatbox");
    var text = "";
    var msg = JSON.parse(evt.data);
    log("消息收到: ");
    console.dir(msg);
    var time = new Date(msg.date);
    var timeStr = time.toLocaleTimeString();

    switch(msg.type) {
      case "id":
        clientID = msg.id;
        setUsername();
        break;

      case "username":
        text = "<b>User <em>" + msg.name + "</em> signed in at " + timeStr + "</b><br>";
        break;

      case "message":
        text = "(" + timeStr + ") <b>" + msg.name + "</b>: " + msg.text + "<br>";
        break;

      case "rejectusername":
        myUsername = msg.name;
        text = "<b>Your username has been set to <em>" + myUsername +
          "</em> because the name you chose is in use.</b><br>";
        break;

      case "userlist":      //收到更新的用户列表
        handleUserlistMsg(msg);
        break;

      //信令消息:这些消息用于交易WebRTC
      //在视频前的谈判过程中传递信息
      //调用。

      case "video-offer":  //邀请和邀请聊天
        handleVideoOfferMsg(msg);
        break;

      case "video-answer":  // Callee已经答复了我们的报价
        handleVideoAnswerMsg(msg);
        break;

      case "new-ice-candidate": //已收到一名新的ICE候选人
        handleNewICECandidateMsg(msg);
        break;

      case "hang-up": //另一位同事挂断了电话
        handleHangUpMsg(msg);
        break;

      //未知的消息;输出到控制台进行调试。

      default:
        log_error("未知的信息收到了");
        log_error(msg);
    }

      //如果有文本要插入聊天缓冲区，那么现在就插入
      //滚动聊天面板，使新文本可见。

    if (text.length) {
      chatBox.innerHTML += text;
      chatBox.scrollTop = chatBox.scrollHeight - chatBox.clientHeight;
    }
  };
}

//处理单击Send按钮(或按return/enter)
//构建一个“message”对象并将其发送到服务器。
function handleSendButton() {
  var msg = {
    text: document.getElementById("text").value,
    type: "message",
    id: clientID,
    date: Date.now()
  };
  sendToServer(msg);
  document.getElementById("text").value = "";
}

//键盘事件的处理程序。这是用来拦截返回和
//输入键，这样我们就可以调用send()来传输输入的文本
//到服务器。
function handleKey(evt) {
  if (evt.keyCode === 13 || evt.keyCode === 14) {
    if (!document.getElementById("send").disabled) {
      handleSendButton();
    }
  }
}

//创建RTCPeerConnection，它知道如何与我们的
//选择STUN/TURN server，然后使用getUserMedia()查找
//我们的摄像头和麦克风，并将该流添加到连接for
//用于我们的视频通话。然后我们配置事件处理程序来获取
//需要电话通知。

async function createPeerConnection() {
  log("建立连接…");

  //创建一个RTCPeerConnection，它知道如何使用我们选择的
  //STUN服务器。

  myPeerConnection = new RTCPeerConnection({
    iceServers: [     //关于ICE服务器的信息-使用您自己的!
      {
        urls: "turn:" + myHostname,  // A TURN server //一个TURN服务器
        username: "webrtc",
        credential: "turnserver"
      }
    ]
  });

 //为ICE协商过程设置事件处理程序。

  myPeerConnection.onicecandidate = handleICECandidateEvent;
  myPeerConnection.oniceconnectionstatechange = handleICEConnectionStateChangeEvent;
  myPeerConnection.onicegatheringstatechange = handleICEGatheringStateChangeEvent;
  myPeerConnection.onsignalingstatechange = handleSignalingStateChangeEvent;
  myPeerConnection.onnegotiationneeded = handleNegotiationNeededEvent;
  myPeerConnection.ontrack = handleTrackEvent;
}

//由WebRTC层调用，让我们知道什么时候可以
//开始、恢复或重启ICE谈判。

async function handleNegotiationNeededEvent() {
  log("*** 谈判需要");

  try {
    log("---> 创建 offer");
    const offer = await myPeerConnection.createOffer();

    //如果连接还没有达到“稳定”状态，
    //返回给来电者。另一个negotiationneeded事件
    //等国家稳定下来就会被解雇。

    if (myPeerConnection.signalingState != "stable") {
      log("——连接还不稳定;推迟……")
      return;
    }

    // Establish the offer as the local peer's current
    // description.
    //将报价确定为本地同行的当前报价
    //描述。

    log("---> Setting local description to the offer");
    await myPeerConnection.setLocalDescription(offer);

    // Send the offer to the remote peer. 将报价发送给远程对等方。

    log("---> 将要offer送给远程peer对等方");
    sendToServer({
      name: myUsername,
      target: targetUsername,
      type: "video-offer",
      sdp: myPeerConnection.localDescription
    });
  } catch(err) {
    log("*** 在处理协商所需事件时发生以下错误:");
    reportError(err);
  };
}

//当事件在媒体轨道上发生时，由WebRTC层调用
//我们的WebRTC电话。这包括向and添加流的时间
//从调用中删除。
//
//田径项目包括以下领域:
//
// RTCRtpReceiver接收机
// MediaStreamTrack跟踪
// MediaStream[]流
// RTCRtpTransceiver收发器
//
//在我们的示例中，我们只是获取找到的第一个流并附加它
//它指向传入媒体的<video>元素。

function handleTrackEvent(event) {
  log("*** Track event");
  document.getElementById("received_video").srcObject = event.streams[0];
  document.getElementById("hangup-button").disabled = false;
}

//通过转发指定的事件来处理|icecandidate|事件
// ICE候选对象(由本地ICE代理创建)到另一个
//通过信令服务器进行监视。

function handleICECandidateEvent(event) {
  if (event.candidate) {
    log("*** Outgoing ICE candidate: " + event.candidate.candidate);

    sendToServer({
      type: "new-ice-candidate",
      target: targetUsername,
      candidate: event.candidate
    });
  }
}

//处理|iceconnectionstatechange|事件。这将检测
//当ICE连接关闭、失败或断开时。
//
//当ICE代理的状态发生变化时，将调用这个函数。

function handleICEConnectionStateChangeEvent(event) {
  log("*** ICE 连接状态更改为 " + myPeerConnection.iceConnectionState);

  switch(myPeerConnection.iceConnectionState) {
    case "closed":
    case "failed":
    case "disconnected":
      closeVideoCall();
      break;
  }
}

//设置|signalingstatechange|事件处理程序。这将检测到
//信号连接已关闭。
//
//注意:这将实际移动到新的RTCPeerConnectionState枚举
//在属性RTCPeerConnection中返回。connectionState当
//浏览器跟上了最新版本的规范!

function handleSignalingStateChangeEvent(event) {
  log("*** WebRTC 信令状态变为: " + myPeerConnection.signalingState);
  switch(myPeerConnection.signalingState) {
    case "closed":
      closeVideoCall();
      break;
  }
}

//处理|icegatheringstatechange|事件。这让我们知道
// ICE engine目前正在开发:“new”表示没有联网
//然而，“收集”意味着ICE引擎目前正在收集候选数据，
//“完成”的意思是收集工作已经完成。注意引擎可以
//根据需要在“收集”和“完成”之间反复切换
//环境变化。
//
//当这种情况发生时，我们不需要做任何事情，但是我们把它记录到
// console，这样您就可以看到在处理示例时发生了什么。

function handleICEGatheringStateChangeEvent(event) {
  log("*** ICE 状态变为: " + myPeerConnection.iceGatheringState);
}

//给定一个包含用户名列表的消息，这个函数
//用这些名称填充用户列表框，生成每个项目
//点击允许开始视频通话。

function handleUserlistMsg(msg) {
  var i;
  var listElem = document.querySelector(".userlistbox");

//删除所有当前列表成员。我们可以做得更聪明，
//通过添加和更新用户，而不是重新构建用户
//抓一下，但是这个样品可以。

  while (listElem.firstChild) {
    listElem.removeChild(listElem.firstChild);
  }

  // 从接收列表中添加成员名。

  msg.users.forEach(function(username) {
    var item = document.createElement("li");
    item.appendChild(document.createTextNode(username));
    item.addEventListener("click", invite, false);

    listElem.appendChild(item);
  });
}

//关闭RTCPeerConnection并重置变量，以便用户可以
//如果他们愿意，再打或再接一个电话。这个叫做both
//当用户挂起时，另一个用户也挂起，或者如果有连接
//检测到故障。

function closeVideoCall() {
  var localVideo = document.getElementById("local_video");

  log("关闭电话");

  // 关闭RTCPeerConnection

  if (myPeerConnection) {
    log("--> 关闭对等连接");

//断开所有事件监听器;我们不希望发生意外
//干扰正在进行的挂起。

    myPeerConnection.ontrack = null;
    myPeerConnection.onnicecandidate = null;
    myPeerConnection.oniceconnectionstatechange = null;
    myPeerConnection.onsignalingstatechange = null;
    myPeerConnection.onicegatheringstatechange = null;
    myPeerConnection.onnotificationneeded = null;

    // 停止连接上的所有收发器

    myPeerConnection.getTransceivers().forEach(transceiver => {
      console.log(transceiver)
      // transceiver.stop();
    });

    //通过暂停<video>来停止网络摄像头预览
    //元素，然后停止每个getUserMedia()跟踪

    if (localVideo.srcObject) {
      localVideo.pause();
      localVideo.srcObject.getTracks().forEach(track => {
        track.stop();
      });
    }

    //关闭对等连接

    myPeerConnection.close();
    myPeerConnection = null;
    webcamStream = null;
  }

  // 禁用挂断按钮

  document.getElementById("hangup-button").disabled = true;
  targetUsername = null;
}

//处理“挂起”消息，如果另一个对等方发送该消息
//已挂掉电话或已断开连接。

function handleHangUpMsg(msg) {
  log("*** 收到其他同事的挂断通知");

  closeVideoCall();
}

//那就把电话挂了，挂掉我们的电话线吧
//给对方发一个“挂断”的信息(记住这一点
//信令是在另一个连接上完成的)。这个通知
//应该终止连接的另一个对等点和UI
//返回“no call in progress”状态。

function hangUpCall() {
  closeVideoCall();

  sendToServer({
    name: myUsername,
    target: targetUsername,
    type: "hang-up"
  });
}

//通过邀请被单击的对象来处理对用户列表中的项的单击
//用户进行视频聊天。注意，我们实际上并没有发送消息给
//这里的被调用者——调用RTCPeerConnection.addTrack()问题
// | notificationrequired | event，因此让我们的处理程序来处理它
//提出建议。

async function invite(evt) {
  log("Starting to prepare an invitation");
  if (myPeerConnection) {
    alert("你不能打电话，因为你已经有一个电话了!");
  } else {
    var clickedUsername = evt.target.textContent;

    // 不允许用户称呼自己，因为很奇怪。

    if (clickedUsername === myUsername) {
      alert("恐怕我不能让你自言自语。那太奇怪了。");
      return;
    }

    // 记录被调用的用户名，以备将来参考

    targetUsername = clickedUsername;
    log("Inviting user " + targetUsername);

    //调用createPeerConnection()创建RTCPeerConnection。
    //当这个返回时，myPeerConnection是我们的RTCPeerConnection
    //而webcamStream是来自摄像机的流。他们是
    //还没有任何联系。

    log("设置连接以邀请用户: " + targetUsername);
    createPeerConnection();

    //访问webcam流并将其附加到
    //“预览”框(id“local_video”)。

    try {
      webcamStream = await navigator.mediaDevices.getUserMedia(mediaConstraints);
      document.getElementById("local_video").srcObject = webcamStream;
    } catch(err) {
      handleGetUserMediaError(err);
      return;
    }

    // 将流中的跟踪添加到RTCPeerConnection

    try {
      webcamStream.getTracks().forEach(
        transceiver = track => myPeerConnection.addTransceiver(track, {streams: [webcamStream]})
      );
    } catch(err) {
      handleGetUserMediaError(err);
    }
  }
}

//接受视频聊天的邀请。我们配置本地设置，
//创建我们的RTCPeerConnection，获取并附加我们的本地相机
//流，然后创建并发送一个答案给调用者。

async function handleVideoOfferMsg(msg) {
  targetUsername = msg.name;

//如果我们还没有连接，创建一个RTCPeerConnection
//连接到来电者。

  log("收到视频聊天邀请 " + targetUsername);
  if (!myPeerConnection) {
    createPeerConnection();
  }

//我们需要将远程描述设置为接收到的SDP报价
//这样我们本地的WebRTC层就知道如何与调用者对话。

  var desc = new RTCSessionDescription(msg.sdp);

  // 如果连接还不稳定，等待它…

  if (myPeerConnection.signalingState != "stable") {
    log("  -但信号状态不稳定，因此触发回滚");

//设置本地和删除回滚描述;不进行
//直到他们都回来。
    await Promise.all([
      myPeerConnection.setLocalDescription({type: "rollback"}),
      myPeerConnection.setRemoteDescription(desc)
    ]);
    return;
  } else {
    log ("  - 设置远程描述");
    await myPeerConnection.setRemoteDescription(desc);
  }

 //如果我们还没有webcam流，就使用它

  if (!webcamStream) {
    try {
      webcamStream = await navigator.mediaDevices.getUserMedia(mediaConstraints);
    } catch(err) {
      handleGetUserMediaError(err);
      return;
    }

    document.getElementById("local_video").srcObject = webcamStream;

  //将摄像机流添加到RTCPeerConnection

    try {
      webcamStream.getTracks().forEach(
        transceiver = track => myPeerConnection.addTransceiver(track, {streams: [webcamStream]})
      );
    } catch(err) {
      handleGetUserMediaError(err);
    }
  }

  log("---> 创建并发送应答给调用者");

  await myPeerConnection.setLocalDescription(await myPeerConnection.createAnswer());

  sendToServer({
    name: myUsername,
    target: targetUsername,
    type: "video-answer",
    sdp: myPeerConnection.localDescription
  });
}

//响应发送给来电者的“视频应答”消息
//一旦被访人决定接受我们的谈话要求，我们就可以开始了。

async function handleVideoAnswerMsg(msg) {
  log("*** 电话接收者已接受我们的电话");

//配置远程描述，这是SDP有效负载
//在我们的“视频回答”信息中。

  var desc = new RTCSessionDescription(msg.sdp);
  await myPeerConnection.setRemoteDescription(desc).catch(reportError);
}

//从另一位同事那里收到了一位新的ICE候选人。调用
// RTCPeerConnection.addIceCandidate()将其发送到
//局部冰框架。

async function handleNewICECandidateMsg(msg) {
  var candidate = new RTCIceCandidate(msg.candidate);

  log("*** 添加接收到的ICE候选项: " + JSON.stringify(candidate));
  try {
    await myPeerConnection.addIceCandidate(candidate)
  } catch(err) {
    reportError(err);
  }
}

// 处理试图访问本地媒体时发生的错误
//硬件;也就是说，getUserMedia()抛出异常。两个最
//可能的情况是用户没有相机和/或麦克风
//或者他们拒绝分享他们的设备。如果
//他们只是选择不分享他们的媒体，这并不是真正的
//错误，所以在这种情况下我们不会给出消息。

function handleGetUserMediaError(e) {
  log_error(e);
  switch(e.name) {
    case "NotFoundError":
      alert("无法打开您的电话，因为没有相机和/或麦克风" +
            "准备好了去设备完善吧");
      break;
    case "SecurityError":
    case "PermissionDeniedError":
      //什么都不做;这与用户取消调用是一样的。
      break;
    default:
      alert("打开相机和/或麦克风时出错: " + e.message);
      break;
  }

//确保我们关闭了RTCPeerConnection的端，这样我们就可以
//准备再试一次。

  closeVideoCall();
}

//处理报告错误。目前，我们只是将内容转储到console，但是
//在实际应用程序中，适当的(用户友好的)
//应该显示错误消息。

function reportError(errMessage) {
  log_error(`Error ${errMessage.name}: ${errMessage.message}`);
}

/*
1.有人要求视频时没有提醒；
*/